Skip to content

feat(marketplace): add curated upstream sources (closes #1136)#1145

Open
sergio-sisternes-epam wants to merge 13 commits intomainfrom
feature/marketplace-upstreams-1136
Open

feat(marketplace): add curated upstream sources (closes #1136)#1145
sergio-sisternes-epam wants to merge 13 commits intomainfrom
feature/marketplace-upstreams-1136

Conversation

@sergio-sisternes-epam
Copy link
Copy Markdown
Collaborator

Description

Adds marketplace upstreams -- a curator-side feature that lets an APM
marketplace selectively re-expose plugins from external Claude Code
marketplaces (e.g. third-party marketplace.json repos) under an
allow-list governance model with immutable commit pinning, while keeping
the emitted marketplace.json strictly Anthropic-conformant (no APM
extensions in source objects).

The curator controls which external repos and which plugins are
visible. Consumers see the curator's marketplace as the single source of
truth and continue to fetch plugin content from the original git host
(no rehosting in v1).

Why

Internal / private marketplaces (e.g. enterprise curators) need to
re-expose useful upstream plugins under a vetted name without
maintaining forks. The existing model required the curator to fork or
re-author every plugin. Upstreams provide a thinner pass-through with:

  • Allow-list governance: only registered upstream aliases can be
    referenced; unknown aliases fail closed at pack time.
  • Commit pinning: every emitted entry is locked to a 40-char SHA;
    HEAD-floats are rejected unless --allow-head is set explicitly per
    upstream.
  • Repo-rename guard: lockfile records canonical_full_name;
    silent rename of an upstream repo fails the next pack.
  • Reproducibility: two consecutive apm pack runs produce
    byte-identical marketplace.json from the lockfile.
  • No surprise mirroring: consumer install still fetches from the
    upstream host, so the curator does not become a CDN.

Fixes #1136

What changed

New CLI surface under apm marketplace:

  • apm marketplace upstream add <owner/repo> --alias <name> --ref <sha>
  • apm marketplace upstream list
  • apm marketplace upstream remove <alias>
  • apm marketplace package add --upstream <alias> --plugin <name> [--name <emit-as>]
    (the SOURCE argument and --upstream are mutually exclusive)

New schema (apm.yml marketplace.upstreams:):

marketplace:
  upstreams:
    - alias: gitnexus
      repo: abhigyanpatwari/GitNexus
      ref: cbe5dac8b7590efc11445032cd1ded6b64fe784e
  packages:
    - name: acme-gitnexus
      upstream: gitnexus
      plugin: gitnexus

New lockfile section (apm.lock.yaml.upstreams:):
records host, owner, repo, manifest_sha, canonical_full_name,
and per-emitted-plugin {emitted_as, resolved_sha, resolved_source}
for full provenance and rename-guard. resolved_sha is typed
str | None so the pin_source: branch-head (allow-head opt-in)
round-trip preserves "unpinned" faithfully.

Builder integration: MarketplaceBuilder now emits a single
marketplace.json combining direct + upstream packages; upstream
source objects are pass-through with path, ref, sha set. The
upstream-resolver wiring is encapsulated in a dedicated
_UpstreamResolverFactory (replacing a 100+ line nested-closure
stack in the original draft).

Bug fix: apm pack previously printed
Built marketplace.json (0 package(s)) when only upstream packages
were present (len(report.resolved) excluded report.upstream_resolved).
Counts now sum both tuples.

Trust model (v1)

Property v1
Allow-list of upstream sources YES
Immutable commit pinning YES
Repo-rename / takeover guard YES
Byte-identical reproducibility YES
Consumer-side custody / takedown shield NO
Curator rehosts plugin content NO

Consumer-side custody is reserved for v2 (distribution: rehost).

Type of change

  • Bug fix (apm pack count)
  • New feature (marketplace upstreams)
  • Documentation
  • Maintenance / refactor (regex consolidation, resolver factory
    extraction, lockfile typing)

Testing

  • Tested locally (full E2E against a real upstream -- see below)
  • All existing tests pass (uv run --extra dev pytest tests/unit -q
    -> 7549 passed, 1 warning, 29 subtests)
  • Added tests for new functionality (165 new upstream-focused
    unit tests across 8 files; see breakdown below)
  • Lint pair silent (ruff check src/ tests/,
    ruff format --check src/ tests/)
  • Formal tests/integration/ E2E for marketplace upstreams
    (deferred -- see "Out of scope" below for rationale; manual
    local E2E in this PR substitutes until then)

Unit coverage by layer

The upstream feature is layered: schema -> parser -> cache ->
resolver -> builder -> lockfile -> editor -> CLI. Each layer has a
dedicated test module. Counts below are upstream-focused tests
introduced by this PR.

Layer Test file Tests What is exercised
Schema test_yml_schema_upstreams.py 23 marketplace.upstreams[] accepts ref or allow_head: true; rejects path traversal, invalid alias / repo / host, conflicting ref + allow_head, duplicate aliases.
Parser test_upstream_parser.py 42 Strict parser of fetched marketplace.json: required keys, plugin shape, source-object shape (git-subdir only in v1), refusal of APM extensions, non-utf8 / oversized / malformed input, fail-closed on unknown plugin types.
Cache test_upstream_cache.py 32 Content-addressed cache keyed by (host, owner, repo, sha): hit / miss, atomic write order (manifest first, meta last -- partial entries skipped on read), TOCTOU rename, stale-meta detection, eviction safety, integrity on read.
Resolver test_upstream_resolver.py 21 Precedence ladder ref -> tag -> branch + allow_head; rejects HEAD-float without opt-in; surfaces named errors (UpstreamUnknownAlias, UpstreamRepoRenamed, UpstreamHeadNotAllowed); host-resolver caching; canonical-name guard.
Builder test_builder_upstream_integration.py 9 Builder pipeline with mixed direct + upstream entries: emits Anthropic-conformant marketplace.json, populates lockfile upstreams: block, emits git-subdir source with path + ref + sha. Includes (new this round) a byte-identical reproducibility trap and a "direct emission must not be perturbed by upstream additions" trap.
Lockfile test_lockfile_upstreams.py 7 apm.lock.yaml round-trip for upstreams: block: manifest_sha, canonical_full_name, per-plugin {emitted_as, resolved_sha, resolved_source}; pin_source: branch-head round-trips resolved_sha=None faithfully.
Editor test_yml_editor_upstreams.py 15 In-place edits to apm.yml: alias add / remove preserves comments and key ordering; duplicate-alias is rejected at the editor layer; --allow-head and --ref mutual exclusion.
CLI test_marketplace_upstream.py 16 Click invocation of upstream add / list / remove and package add --upstream: success paths, mutual-exclusion errors, duplicate-alias hard-fails at exit code 2 (regression trap added this round), list rendering, missing-alias errors.

Builder-layer integration coverage

test_builder_upstream_integration.py exercises the builder with
real ref-resolver / upstream-resolver / lockfile-writer wiring (the
git network calls are the only thing stubbed, via a MagicMock
fetch_callback). It is the closest thing to an integration test
we have for this feature and covers:

  • Mixed apm.yml (direct + upstream entries) -> single emitted
    marketplace.json with both subsets present.
  • Source-object shape: git-subdir with path, ref, sha all
    set on every upstream entry; no APM extensions leak through.
  • Lockfile upstreams: block correctly populated with
    manifest_sha and canonical_full_name; per-plugin
    resolved_sha matches the upstream marketplace.json source.
  • --allow-head opt-in path: pin_source: branch-head,
    resolved_sha=None, no SHA in emitted source object.
  • Two-pass byte-identical reproducibility (NEW): a second
    MarketplaceBuilder.build() over the same inputs produces
    byte-identical output.
  • Direct-emission isolation (NEW): adding an upstream entry to a
    mixed apm.yml must not change the direct subset's emitted JSON
    byte-for-byte.

Local E2E (real upstream, manual)

Executed against the public repo abhigyanpatwari/GitNexus (a real
Claude Code marketplace) at a pinned SHA. This is a manual
end-to-end run; it is not wired into tests/integration/ or
scripts/test-integration.sh (see "Out of scope" below).

git ls-remote git@github.com:abhigyanpatwari/GitNexus.git HEAD
-> cbe5dac8b7590efc11445032cd1ded6b64fe784e

apm marketplace upstream add abhigyanpatwari/GitNexus \
    --alias gitnexus --ref cbe5dac8b7590efc11445032cd1ded6b64fe784e
apm marketplace package add \
    --upstream gitnexus --plugin gitnexus --name acme-gitnexus
apm pack
-> Built marketplace.json (1 package(s))

Verified:

  • Emitted marketplace.json is Anthropic-conformant (no
    metadata.apm.* keys); source object is git-subdir with
    path: gitnexus-claude-plugin, ref and sha both pinned.
  • apm.lock.yaml.upstreams.gitnexus populated with full
    provenance: manifest_sha, canonical_full_name,
    per-plugin resolved_sha.
  • A second apm pack produced byte-identical marketplace.json
    (reproducibility gate met against a real upstream, not just the
    unit-level mock).
  • Failure-mode spot checks (each must abort with the named error
    and a non-zero exit code, leaving lockfile and marketplace.json
    unchanged):
    • Reference an unknown alias in marketplace.packages[]
      -> UpstreamUnknownAlias.
    • Mutate the alias repo: to a different owner/repo while the
      lockfile still records the old canonical_full_name
      -> UpstreamRepoRenamed.
    • Move a branch-pinned alias without --allow-head
      -> UpstreamHeadNotAllowed.

Out of scope (deferred)

  • apm doctor upstream health check.
  • apm.yml policy.upstreams.* governance keys.
  • CI integration test wiring (scripts/test-integration.sh).
  • Consumer-side apm install E2E from a real curator marketplace
    (requires the curator repo to be hosted; not picked autonomously).

Documentation

  • docs/src/content/docs/guides/marketplace-upstreams.md (new guide).
    Quick-start uses a real GitNexus SHA + a git ls-remote recipe;
    CLI reference table distinguishes SHA / tag / branch+--allow-head
    rows; "Tag vs SHA: when to use which" subsection; new "Failure
    modes" subsection covering the three named errors curators will hit
    (unknown alias, repo-rename, moving branch without --allow-head).
  • docs/src/content/docs/enterprise/security.md (rewrote the
    "no proxy, no mirror" claim to reflect the curated pass-through
    model and link to the new guide).
  • packages/apm-guide/.apm/skills/apm-usage/commands.md (4 new rows).
  • packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
    (upstreams: schema example).
  • CHANGELOG.md [Unreleased] entry rewritten as a one-line hook +
    bullets, linking to the rendered docs URL
    (https://microsoft.github.io/apm/guides/marketplace-upstreams/).

Review history

This branch went through a local APM Review Panel pass before
opening. The first round (2da2d39a) landed all blocking-tier
findings; the second round (7153f90b) lands the long tail of
recommended + nit findings: the _UpstreamResolverFactory
extraction, the 6-site SHA-40 regex consolidation into
ref_resolver.FULL_SHA_RE, the lockfile typing fix, the CLI symbol
nits, the doc additions called out above, and three new
regression-trap tests.

Copilot AI review requested due to automatic review settings May 5, 2026 13:37
@sergio-sisternes-epam sergio-sisternes-epam added panel-review Trigger the apm-review-panel gh-aw workflow status/triaged Initial agentic triage complete; pending maintainer ratification (silence = approval). labels May 5, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds marketplace upstream authoring to APM so curators can expose selected plugins from external marketplaces, plus the supporting parser/resolver/cache/lockfile wiring and docs/tests around that flow.

Changes:

  • Adds marketplace.upstreams, upstream-sourced packages[] entries, and new apm marketplace upstream ... / package add --upstream ... CLI paths.
  • Introduces upstream fetch/parse/resolve/cache/lockfile plumbing and fixes apm pack package counts for upstream-only builds.
  • Adds broad unit coverage and updates public/internal docs for the new authoring model.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tests/unit/marketplace/test_yml_schema_upstreams.py Covers schema parsing and validation for upstream config.
tests/unit/marketplace/test_yml_editor_upstreams.py Covers round-trip YAML editing helpers for upstream entries.
tests/unit/marketplace/test_upstream_resolver.py Exercises upstream resolution precedence, diagnostics, and guards.
tests/unit/marketplace/test_upstream_parser.py Covers strict upstream manifest parsing and rejection reasons.
tests/unit/marketplace/test_upstream_cache.py Tests cache keys, integrity checks, and fetch/auth behavior.
tests/unit/marketplace/test_lockfile_upstreams.py Verifies upstream provenance serialization into apm.lock.yaml.
tests/unit/marketplace/test_builder_upstream_integration.py Tests builder output for mixed direct/upstream marketplace builds.
tests/unit/commands/test_pack_upstream_count.py Pins the apm pack package-count regression fix.
tests/unit/commands/test_marketplace_upstream.py Covers new upstream CLI commands and package add --upstream.
src/apm_cli/marketplace/yml_schema.py Adds upstream dataclasses, parsing, and validation.
src/apm_cli/marketplace/yml_editor.py Adds YAML editor helpers for upstream CRUD and package insertion.
src/apm_cli/marketplace/upstream_resolver.py Implements upstream package resolution and diagnostics.
src/apm_cli/marketplace/upstream_parser.py Adds strict parser for fetched upstream marketplace.json files.
src/apm_cli/marketplace/upstream_cache.py Adds SHA-keyed cache and GitHub API fetch path for upstream manifests.
src/apm_cli/marketplace/ref_resolver.py Exposes shared full-SHA regex for marketplace modules.
src/apm_cli/deps/lockfile.py Adds upstream provenance types and YAML round-trip support.
src/apm_cli/commands/pack.py Counts direct and upstream packages in pack output.
src/apm_cli/commands/marketplace/upstream.py Adds upstream add/list/remove command group.
src/apm_cli/commands/marketplace/plugin/add.py Extends package add flow for upstream-sourced packages.
src/apm_cli/commands/marketplace/plugin/__init__.py Reuses shared SHA regex in marketplace plugin helpers.
src/apm_cli/commands/marketplace/__init__.py Registers the new upstream command group.
packages/apm-guide/.apm/skills/apm-usage/package-authoring.md Documents upstream schema and authoring examples.
packages/apm-guide/.apm/skills/apm-usage/commands.md Adds internal command reference rows for upstream flows.
docs/src/content/docs/reference/cli-commands.md Updates public CLI reference for upstream commands/options.
docs/src/content/docs/guides/marketplace-upstreams.md Adds the new marketplace upstreams guide.
docs/src/content/docs/enterprise/security.md Updates security/trust wording for curated upstream pass-through.
docs/astro.config.mjs Adds sidebar navigation entry for the new guide.
CHANGELOG.md Adds unreleased changelog entry for marketplace upstreams.

Comment on lines +322 to +332
# -- assembly ---------------------------------------------------------------

def build(self) -> UpstreamResolver:
upstreams_by_alias = {u.alias: u for u in self._yml.upstreams}
# See ``MarketplaceBuilder._build_upstream_resolver`` for the
# rationale on ``canonical_full_name=None`` in v1.
return UpstreamResolver(
upstreams=upstreams_by_alias,
cache=UpstreamCache(),
ref_to_sha=self.ref_to_sha,
canonical_full_name=None,
Comment on lines +641 to +650
# Claude Code uses ``type``; the lenient parser also tolerates an
# alternate ``source`` key inside the dict. Strict parsing requires
# ``type`` to be set explicitly so that ``unsupported-source-type``
# rejections name the actual offending value.
source_type = raw.get("type", "")
if not isinstance(source_type, str) or not source_type:
return StrictRejection(
plugin_name=name,
reason="invalid-source-shape",
detail=f"plugin '{name}' source object missing 'type' discriminator",
Comment on lines +273 to +277
if remote.name == f"refs/tags/{ref_or_branch}":
return remote.sha
if remote.name == f"refs/heads/{ref_or_branch}":
return remote.sha
raise BuildError(f"ref '{ref_or_branch}' not found in {owner}/{repo}")
Comment on lines +100 to +104
# v1 host allow-list. Cross-host plugins (upstream on github, plugin on
# gitlab) are explicitly out of scope -- the plan reserves multi-host
# for v2 to avoid expanding the auth surface area in this slice.
_SUPPORTED_HOSTS: frozenset[str] = frozenset({"github.com"})

Comment thread src/apm_cli/marketplace/yml_editor.py Outdated
Comment on lines +311 to +319
_UPSTREAM_ALIAS_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
_UPSTREAM_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")


def _validate_upstream_alias(alias: str) -> None:
if not _UPSTREAM_ALIAS_RE.match(alias):
raise MarketplaceYmlError(
f"Upstream alias '{alias}' must start with a letter and contain "
f"only letters, digits, '_' or '-'."
Comment on lines +1417 to +1428
def _write_upstream_lockfile(self, upstream_resolved: list[ResolvedUpstreamPackage]) -> None:
"""Persist upstream provenance into ``apm.lock.yaml``.

For every successfully resolved upstream package, record the
upstream marketplace coordinates (host/owner/repo/path/manifest
SHA) and the per-plugin pin (resolved SHA + emitted display
name + resolved source dict). The lockfile is the **only**
place provenance lives -- ``marketplace.json`` stays
Anthropic-conformant with no APM-specific keys.

Reads the existing lockfile (if any), preserves all unrelated
sections, replaces the ``upstreams`` block, and writes back.
```bash
apm marketplace upstream add OWNER/REPO --alias ALIAS [OPTIONS]
apm marketplace upstream list [--verbose]
apm marketplace upstream remove ALIAS [--yes]
Comment thread CHANGELOG.md Outdated
Comment on lines +12 to +17
- **Marketplace upstreams: curated pass-through with allow-list governance.** Selectively re-expose plugins from an external marketplace (for example, a public Claude Code marketplace) under your own marketplace, with build-time commit pinning baked into `apm.lock.yaml`.
- New `apm marketplace upstream` CLI subgroup: `add` / `list` / `remove`.
- New `upstreams:` block in `apm.yml -> marketplace:`; `packages[]` entries pick between the existing `source:` shape and the new `upstream:` + `plugin:` shape (mutually exclusive per entry).
- Emitted `marketplace.json` is byte-for-byte Anthropic-conformant -- **no `metadata.apm.*` keys are injected**, so consumers see a vanilla plugin entry.
- APM does **not** re-host upstream content. Consumer installs always fetch plugin source from the upstream git host; air-gapped re-hosting is reserved for a future `distribution: rehost` mode.
- See the [Marketplace upstreams guide](https://microsoft.github.io/apm/guides/marketplace-upstreams/). (#1136)
Comment on lines +487 to +490
if entry.ref is not None:
ref = entry.ref
sha = self._maybe_sha(ref)
return ref, sha, "curator-ref"
Comment on lines +522 to +527
# 3. Upstream plugin's pin (sha preferred, else tag-style ref).
if plugin.source.sha is not None:
return plugin.source.sha, plugin.source.sha, "upstream-pin"
if plugin.source.ref is not None:
ref = plugin.source.ref
return ref, self._maybe_sha(ref), "upstream-pin"
@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label May 5, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 5, 2026

APM Review Panel: needs_rework

Marketplace upstreams ships a compelling enterprise governance capability, but the rename/takeover guard advertised as active in the trust table is fully disabled -- fix or explicitly defer before wide rollout.

cc @sergio-sisternes-epam @danielmeppiel -- a fresh advisory pass is ready for your review.

This PR delivers a well-designed, layered feature that directly advances APM's enterprise positioning: curators can now selectively re-expose plugins from external marketplaces under allow-list governance with immutable commit pinning. The architecture is solid (frozen dataclasses, DI, clear module boundaries), the 165-test suite covers the unit surface thoroughly, and the new guide gives enterprise evaluators a credible onboarding path. The panel is largely aligned -- no cross-panelist disagreements on severity -- and the auth delegation, CLI surface, and doc quality are all above baseline.

Two signals dominate arbitration and must be resolved before broad adoption is encouraged. First: supply-chain-security and python-architect independently converged on the same finding from different angles -- the repo-rename/takeover guard is advertised as active in the trust table ('Repo-rename / takeover guard: YES') but _UpstreamResolverFactory.build() hard-codes canonical_full_name=None, making the guard permanently unreachable. Security frames this as a trust-table deception; architecture frames it as a silent documentation mismatch. Both are correct. Shipping a trust table that makes an affirmative security claim against code that falsifies it is the one issue in this PR that cannot be papered over with a follow-up issue -- the table must match reality before merge. The fix is a line change and a doc update, not a rearchitecture. Second: test-coverage flags that the entire upstream add -> build -> lockfile write cross-module flow has zero integration-with-fixtures coverage -- all upstream paths are exercised only through MagicMock boundaries. Per the CEO persona contract, outcome: missing on a governed-by-policy + secure-by-default surface inherits blocking-tier weight. Silent drift in the resolver-to-lockfile handoff would not be caught by any currently running test against real fixture files.

All remaining findings are addressable post-merge without security or correctness risk: the --yes flag missing from upstream remove, the --plugin silent default footgun, the pack count opacity, the cache content-hash gap, and the doc improvements are all high-value but non-blocking. The OSS growth hacker's framing is correct -- this is the strongest enterprise-acquisition surface APM has shipped -- and the CHANGELOG and guide should be tightened to match that positioning as part of release prep.

Aligned with: Secure by default -- the rename guard trust-table gap is the single acute issue; cache content-hash hardening is recommended but not correctness-critical. Governed by policy -- allow-list, SHA pinning, and lockfile provenance are correctly implemented at unit tier; integration-with-fixtures snapshot test is missing. Portable by manifest -- lockfile round-trip passes at unit tier; integration-with-fixtures gap remains. OSS community driven -- new guide lowers barrier for community curators; cross-link from marketplace-authoring and SHA placeholder fix will improve first-contributor success. Pragmatic as npm -- --plugin silent default and missing --yes on upstream remove are the two ergonomic rough edges.

Growth signal. The OSS growth hacker's read is accurate: marketplace upstreams is APM's first feature that directly answers 'how do we govern AI tooling company-wide' at enterprise scale. Repositioning the CHANGELOG entry and guide opening around that outcome -- 'Ship vetted AI capabilities company-wide without maintaining forks' -- before the tagged release turns a curator-tool addition into an enterprise-platform story. A coordinated Show HN timed to the first tagged release would be high-ROI.

Panel summary

Persona B R N Takeaway
Python Architect 0 3 2 Solid layered design with good DI and frozen dataclasses; three issues worth addressing: _REMOTE_SOURCE_RE duplicated 3x, _UpstreamResolverFactory back-references builder privates, and the rename guard is silently disabled in v1 without surfacing in security docs.
CLI Logging Expert 0 1 2 Output quality is solid overall; two nits on pluralisation and tree_item semantic misuse; one recommended on pack count transparency.
DevX UX Expert 0 3 2 CLI surface is solid and well-documented; two concrete issues: docs advertise --yes on upstream remove but it is unimplemented, and --plugin silently defaulting to --name creates a confusing footgun.
Supply Chain Security Expert 1 1 1 Repo-rename guard advertised as active but never wired in builder; cache integrity checks commit SHA not content bytes -- two supply-chain gaps worth addressing before wide rollout.
OSS Growth Hacker 0 4 2 Solid enterprise feature with a well-structured guide, but the quick-start breaks the 60-second runnable test, limitations are front-loaded, and the release hook is buried.
Auth Expert 0 0 2 Upstream fetch correctly delegates to AuthResolver.try_with_fallback with per-host credential scoping; one nit on unauth_first for private upstreams.
Doc Writer 0 2 2 New guide is well-structured and accurate; two planned-feature call-outs are missing per PROSE rules, and upstream add required-arg gap needs fixing in the CLI ref.
Test Coverage Expert 1 2 0 165 unit tests present and correct; all cross-module upstream flows (build->lockfile, CLI->disk) lack integration-with-fixtures tier coverage per surface floor.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Supply Chain Security Expert] (blocking-severity) Wire canonical_full_name into the rename/takeover guard or update the trust table to 'NO (deferred to v2)' -- The trust table makes an affirmative security claim ('Repo-rename / takeover guard: YES') that the code falsifies. This is the only finding that is both a correctness issue and a documentation deception -- it must match reality before merge.
  2. [Test Coverage Expert] (blocking-severity) Add integration-with-fixtures test covering upstream add -> build -> lockfile write end-to-end against real fixture files -- The entire cross-module upstream build pipeline is covered only by MagicMock unit tests. outcome:missing on a governed-by-policy + secure-by-default surface inherits blocking-tier weight; silent drift in resolver-to-lockfile handoff would go undetected.
  3. [DevX UX Expert] Either implement --yes on upstream remove or remove it from all doc locations -- docs advertise [--yes] but the option does not exist; users who copy-paste the synopsis get a runtime error. The peer command already has the flag, so user expectation is set.
  4. [DevX UX Expert] Remove the silent --plugin default to --name when --upstream is set; require --plugin explicitly -- Silent default causes a cryptic 'plugin not found in upstream' error when a curator writes --name as a local alias. The footgun is invisible at the call site.
  5. [Supply Chain Security Expert] Add content_sha256 to upstream cache meta.json and verify on every get() -- Current integrity check confirms the right commit SHA index but does not verify the fetched manifest bytes; tampered cache content passes the check. Adding a content hash closes the gap with minimal implementation cost.

Architecture

classDiagram
    direction LR

    class MarketplaceBuilder {
        <<Facade>>
        +resolve() ResolveResult
        +build() BuildReport
        +compose_marketplace_json() dict
        -_build_upstream_resolver(yml) UpstreamResolver
    }

    class _UpstreamResolverFactory {
        <<Factory>>
        -_builder MarketplaceBuilder
        -_host_resolvers dict
        +ref_to_sha(host,owner,repo,ref) str
        +version_range(host,owner,repo,range) tuple
        +build() UpstreamResolver
    }

    class UpstreamResolver {
        <<Facade>>
        -_upstreams dict
        -_cache UpstreamCache
        -_ref_to_sha RefToShaResolver
        -_canonical_full_name CanonicalFullNameResolver
        -_version_range_resolver VersionRangeResolver
        -_fetched dict
        -_diagnostics list
        +resolve_all(entries) tuple
        +resolve_package(entry) ResolvedUpstreamPackage
        -_get_or_fetch_upstream(upstream) _UpstreamFetchRecord
        -_apply_precedence_ladder(entry,plugin,upstream) tuple
    }

    class UpstreamCache {
        <<IOBoundary>>
        -_base_dir Path
        -_fetch_callback Callable
        +get(key) dict
        +put(key, manifest) None
        +get_or_fetch(key) dict
    }

    class UpstreamCacheKey {
        <<ValueObject>>
        +host str
        +owner str
        +repo str
        +sha str
        +path str
        +composite str
        +fingerprint str
        +directory_name str
    }

    class ResolvedUpstreamPackage {
        <<ValueObject>>
        +entry UpstreamPackageEntry
        +upstream Upstream
        +plugin StrictPlugin
        +plugin_sha str
        +upstream_manifest_sha str
        +pin_source str
    }

    class UpstreamResolverDiagnostic {
        <<ValueObject>>
        +level str
        +code str
        +message str
        +upstream_alias str
    }

    class _UpstreamFetchRecord {
        +manifest StrictManifest
        +manifest_sha str
        +canonical_full_name str
    }

    class Upstream {
        <<ValueObject>>
        +alias str
        +repo str
        +ref str
        +branch str
        +host str
        +allow_head bool
    }

    class UpstreamPackageEntry {
        <<ValueObject>>
        +name str
        +upstream_alias str
        +plugin str
        +ref str
        +version str
    }

    class StrictManifest {
        +plugins list
        +rejections list
        +find_plugin(name) StrictPlugin
    }

    class StrictPlugin {
        <<ValueObject>>
        +name str
        +source StrictPluginSource
    }

    class LockedUpstream {
        +alias str
        +manifest_sha str
        +plugins dict
        +to_dict() dict
        +from_dict() LockedUpstream
    }

    class LockFile {
        +upstreams dict
        +to_yaml() str
        +from_yaml() LockFile
    }

    class _UpstreamResolverFactory:::touched
    class UpstreamResolver:::touched
    class UpstreamCache:::touched
    class UpstreamCacheKey:::touched
    class ResolvedUpstreamPackage:::touched
    class UpstreamResolverDiagnostic:::touched
    class _UpstreamFetchRecord:::touched
    class Upstream:::touched
    class UpstreamPackageEntry:::touched
    class StrictManifest:::touched
    class StrictPlugin:::touched
    class LockedUpstream:::touched

    classDef touched fill:#fff3b0,stroke:#d47600

    MarketplaceBuilder *-- _UpstreamResolverFactory : creates
    _UpstreamResolverFactory ..> UpstreamResolver : assembles
    UpstreamResolver *-- UpstreamCache : cache
    UpstreamResolver ..> UpstreamPackageEntry : resolves
    UpstreamResolver ..> Upstream : reads registration
    UpstreamResolver ..> _UpstreamFetchRecord : stores
    UpstreamResolver ..> ResolvedUpstreamPackage : returns
    UpstreamResolver ..> UpstreamResolverDiagnostic : emits
    _UpstreamFetchRecord *-- StrictManifest : holds
    StrictManifest *-- StrictPlugin : contains
    UpstreamCache ..> UpstreamCacheKey : keyed by
    LockFile *-- LockedUpstream : stores
    MarketplaceBuilder ..> Upstream : loads from apm.yml
    MarketplaceBuilder ..> UpstreamPackageEntry : loads from apm.yml

    note for _UpstreamResolverFactory "Factory: builds UpstreamResolver with injected Strategy callables for ref_to_sha / version_range"
    note for UpstreamCache "Content-addressed by 40-char git SHA. Defence-in-depth integrity re-check on every hit."
    note for UpstreamResolver "Collect-then-render: accumulates UpstreamResolverDiagnostic list; builder lifts to BuildDiagnostic rows"
Loading
flowchart TD
    CLI["apm marketplace build"] --> MB["MarketplaceBuilder.build()\nbuilder.py"]
    MB --> LY["[I/O] _load_yml()\nload_marketplace_from_apm_yml()"] --> MYml["MarketplaceYml\n(packages, upstreams)"]
    MYml --> Partition{"resolve()\npartition by isinstance"}

    Partition -->|DirectPackageEntry| Direct["ThreadPoolExecutor\n_resolve_entry() x N\nbuilder.py"]
    Direct --> LSR["[NET] RefResolver.list_remote_refs()\ngit ls-remote"]
    LSR --> RP["ResolvedPackage list"]

    Partition -->|UpstreamPackageEntry| URF["_UpstreamResolverFactory.build()\nbuilder.py"]
    URF --> UR["UpstreamResolver.resolve_all()\nupstream_resolver.py"]

    UR --> GOFT["_get_or_fetch_upstream(upstream)\nupstream_resolver.py"]
    GOFT --> Fetched{"alias in _fetched?"}
    Fetched -->|hit| FetchRecord
    Fetched -->|miss| Rename["[NET] canonical_full_name()\n(None in v1 -- guard skipped)"]
    Rename --> RefSHA["[NET] ref_to_sha()\nRefResolver.list_remote_refs()"]
    RefSHA --> CK["compute_cache_key()\nupstream_cache.py"]
    CK --> UCGet["[FS] UpstreamCache.get(key)\nread manifest.json + meta.json"]
    UCGet --> CacheHit{"integrity valid?"}
    CacheHit -->|yes| Raw
    CacheHit -->|no / miss| Fetch["[NET] _default_fetch_via_github_api()\nGitHub Contents API\nupstream_cache.py"]
    Fetch --> UCPut["[FS] UpstreamCache.put(key)\nwrite manifest.json then meta.json"]
    UCPut --> Raw["raw manifest dict"]
    Raw --> Parse["parse_marketplace_strict()\nupstream_parser.py"]
    Parse --> FetchRecord["_UpstreamFetchRecord\n(manifest, sha, canonical)"]
    FetchRecord --> Ladder["_apply_precedence_ladder()\nupstream_resolver.py\ncurator-ref > version > upstream-pin > same-repo-sha > head"]
    Ladder --> RUP["ResolvedUpstreamPackage"]

    RP --> Compose["compose_marketplace_json()\nbuilder.py"]
    RUP --> Compose
    Compose --> Output["[FS] atomic_write()\nmarketplace.json"]
    RUP --> Lock["[FS] LockFile.upstreams\napm.lock.yaml"]
Loading

Recommendation

Address the two blocking-tier items before merge: (1) align the trust table with the actual behavior of the rename guard -- one line of code or one line of docs, not both claiming opposite things; (2) add at least one integration-with-fixtures test that runs the full upstream build -> lockfile write pipeline against real fixture files on disk. All other panel findings -- --yes on upstream remove, --plugin footgun, cache content hash, doc planned-feature callouts, and the CHANGELOG/guide reframe -- are strong recommended improvements but do not gate merge. Once the two blocking items are resolved, this PR is a clean ship: the architecture is sound, the test count is substantial, and the feature is the strongest enterprise positioning surface APM has delivered.


Full per-persona findings

Python Architect

  • [recommended] _REMOTE_SOURCE_RE regex duplicated verbatim across 3 modules at src/apm_cli/marketplace/upstream_cache.py:107
    upstream_cache.py:107, upstream_parser.py:87, and yml_schema.py:80 each define the identical regex compile call. The PR already consolidated FULL_SHA_RE into ref_resolver -- the same treatment should apply here. A divergence between copies would introduce a silent validation gap in one layer while the others still reject.
    Suggested: Move REMOTE_SOURCE_RE into ref_resolver.py alongside FULL_SHA_RE and import it in all three consumers.

  • [recommended] _UpstreamResolverFactory holds a back-reference to MarketplaceBuilder and calls private methods at src/apm_cli/marketplace/builder.py:238
    _UpstreamResolverFactory.init stores self._builder: MarketplaceBuilder and reaches into self._builder._host, self._builder._options, self._builder._get_resolver(), and self._builder._auth_resolver. This defeats the stated purpose of extracting the factory to improve readability and testability.
    Suggested: Pass specific values at construction time: init(self, *, options, host, get_resolver, auth_resolver, upstreams). The builder assembles the kwargs and passes them; the factory is then independently testable.

  • [recommended] Repo-rename guard is advertised as active but wired as canonical_full_name=None in v1 at src/apm_cli/marketplace/builder.py:332
    The PR body, security docs, and UpstreamResolver docstring all describe the repo-rename guard as a supply-chain protection. In practice, _UpstreamResolverFactory.build() passes canonical_full_name=None, which causes _get_or_fetch_upstream to skip the guard entirely. The security documentation creates a false expectation.
    Suggested: Either (a) add warning log when canonical_full_name is None so operators can see it is skipped, or (b) update security doc and UpstreamResolver docstring to state 'rename guard deferred to v2'.

  • [nit] UpstreamPackageEntry.upstream_alias attribute name diverges from the YAML key 'upstream' at src/apm_cli/marketplace/yml_schema.py:349
    The YAML key is 'upstream', the dataclass field is 'upstream_alias', and the loader must translate between them. Adds friction at every call site.

  • [nit] yml_editor.py carries unused 'from typing import List, Optional' imports at src/apm_cli/marketplace/yml_editor.py:19
    Line 19 imports List and Optional with noqa: UP035 comment, but neither symbol is used anywhere in the file.
    Suggested: Remove the List, Optional import and the noqa comment.

CLI Logging Expert

  • [recommended] pack.py success message silently merges direct + upstream counts with no breakdown at src/apm_cli/commands/pack.py:219
    A curator who adds 3 direct packages and 12 upstream packages sees '15 package(s)' with no signal that upstreams are even involved. When upstream_resolved is non-empty a parenthetical breakdown makes the output self-explanatory.
    Suggested: When upstream_resolved is non-empty emit: 'Built marketplace.json (N direct + M upstream = T package(s)) -> path'. Apply the same pattern to the dry_run_notice line.

  • [nit] tree_item is semantically wrong for upstream list rows at src/apm_cli/commands/marketplace/upstream.py:193
    tree_item is documented as 'Log a tree sub-item' and renders unconditionally in green. In list_cmd the upstream entries are primary content, not package-tree children. If a future reviewer adds a verbose guard to tree_item the list will silently break.
    Suggested: Replace with logger.info per entry or introduce a dedicated logger.list_item helper.

  • [nit] Awkward (s) pluralisation in list_cmd count header at src/apm_cli/commands/marketplace/upstream.py:185
    "N upstream(s) registered:" is the only place in the codebase using the (s) anti-pattern.
    Suggested: noun = 'upstream' if len(entries) == 1 else 'upstreams'; logger.info(f'{len(entries)} {noun} registered:', symbol='info')

DevX UX Expert

  • [recommended] docs advertise upstream remove ALIAS [--yes] but --yes is not implemented at docs/src/content/docs/reference/cli-commands.md:245
    cli-commands.md shows [--yes] in synopsis. User who copy-pastes the documented example gets 'No such option: --yes'. The peer command apm marketplace package remove has --yes, so users will expect it here too.
    Suggested: Either implement --yes on upstream remove, or remove it from all doc locations and add a note that the command self-guards against referenced aliases.

  • [recommended] --plugin silently defaults to --name, creating a confusing footgun when --name is a curator alias at src/apm_cli/commands/marketplace/plugin/add.py:408
    If a curator writes apm marketplace package add --upstream gitnexus --name acme-gitnexus (omitting --plugin), APM looks for a plugin called 'acme-gitnexus' in the upstream. The error ('plugin not found in upstream') will be cryptic because the user never mentioned the upstream plugin name.
    Suggested: Remove the silent default: require --plugin explicitly when --upstream is set (raise UsageError if both are absent).

  • [recommended] apm pack success line hides upstream contribution, making the count opaque at src/apm_cli/commands/pack.py:221
    A curator cannot tell from output whether upstreams resolved correctly or were silently skipped.
    Suggested: When upstream_resolved is non-empty: 'Built marketplace.json (N direct + M upstream = T package(s)) -> path'.

  • [nit] upstream add uses --alias as a required option rather than positional argument, breaking the git-remote-add mental model at src/apm_cli/commands/marketplace/upstream.py:619
    git remote add <name> <url> uses positional args for primary identifiers. apm marketplace upstream add owner/repo --alias name forces an extra flag for something semantically primary.

  • [nit] upstream add requires --ref OR --branch with no default, unlike every other apm add command at src/apm_cli/commands/marketplace/upstream.py:644
    Intentional for safety but the asymmetry will surprise users who follow the pattern from package add.
    Suggested: Add a one-liner to the error message explaining why there is no auto-pin default.

Supply Chain Security Expert

  • [blocking] Rename/takeover guard is always disabled: builder passes canonical_full_name=None at src/apm_cli/marketplace/builder.py:332
    The PR trust-model table explicitly states 'Repo-rename / takeover guard: YES'. However _UpstreamResolverFactory.build() hard-codes canonical_full_name=None, meaning the guard is never reachable. The lockfile records curator-typed canonical_full_name -- not GitHub-reported name -- defeating the guard. A renamed or account-takeover repo silently resolves without warning.
    Suggested: Wire in a real canonical_full_name callable. If v1 deferral intended, document as 'NO (deferred to v2)' in trust table and remove the rename-guard machinery so it cannot mislead auditors.

  • [recommended] Cache integrity verifies commit SHA tag but not content hash of fetched bytes at src/apm_cli/marketplace/upstream_cache.py:324
    upstream_cache.py get() compares meta['sha'] to key.sha. This confirms indexing to the right commit but does NOT verify the manifest.json bytes hash. Tampered cache content passes the integrity check.
    Suggested: In put(), compute sha256 over manifest bytes and store as content_sha256 in meta.json; verify on every get().

  • [nit] Generic except clause may surface raw exception strings including response body fragments at src/apm_cli/marketplace/upstream_cache.py:496
    bare 'except Exception as exc: raise UpstreamCacheError(...{exc}...)' includes str(exc) verbatim. For some network libraries this can embed response body fragments.
    Suggested: Replace with type(exc).name or a pre-scrubbed message.

OSS Growth Hacker

  • [recommended] Quick start requires the reader to run a preflight command -- breaks the 60-second runnable test at docs/src/content/docs/guides/marketplace-upstreams.md
    The example SHA placeholder forces stop, open terminal, run git ls-remote, copy, return to guide. Doubles time-to-first-success; Why do we need a GitHub token? #1 drop-off point for docs conversion.
    Suggested: Embed actual resolved SHA inline in example and add comment: # update this SHA before committing by running: git ls-remote ... HEAD

  • [recommended] Limitations listed prominently after failure modes -- enterprise evaluators will bounce at docs/src/content/docs/guides/marketplace-upstreams.md
    The 'What is NOT supported in v1' section appears after the failure-mode error blocks. Stacking negatives trains readers to associate this feature with what it cannot do.
    Suggested: Rename to 'Roadmap / known gaps', move to bottom, reframe bullets as forward-looking.

  • [recommended] No elevator-pitch hook -- guide opens with tool analogy, not user outcome at docs/src/content/docs/guides/marketplace-upstreams.md
    An enterprise champion needs a sentence to paste into Slack. Hook = conversion.
    Suggested: Prepend: 'Ship vetted AI capabilities company-wide -- without maintaining forks.'

  • [recommended] CHANGELOG entry lacks a tweet-sized release hook at CHANGELOG.md
    The CHANGELOG is raw material for release posts. Entry is accurate but reads like a spec, not a story.
    Suggested: Open CHANGELOG entry with a bolded story sentence before the bullets.

  • [nit] Quick-start example references a real third-party repo -- tutorial breaks if repo renames at docs/src/content/docs/guides/marketplace-upstreams.md
    abhigyanpatwari/GitNexus is a live external resource. If renamed, triggers APM's own rename guard in the tutorial.

  • [nit] Guide sidebar has no cross-link from the marketplace-authoring guide at docs/src/content/docs/guides/marketplace-authoring.md
    A curator who reads marketplace-authoring to completion will never learn upstreams exist.

Auth Expert

  • [nit] try_with_fallback called with unauth_first=True for all upstream hosts including private ones at src/apm_cli/marketplace/upstream_cache.py
    Correct for public upstreams but private upstream repos will always incur an extra 404 round-trip before auth retry. Consider deriving the flag from host_info.has_public_repos or documenting the limitation.

  • [nit] Private upstream repos cannot resolve non-SHA refs in v1 at src/apm_cli/marketplace/builder.py
    _resolver_for_host returns token=None for upstream hosts differing from the curator host. A changelog note would help curators trying to use private upstream repos.

Doc Writer

  • [recommended] upstream add: --ref or --branch is required but docs list both as optional at docs/src/content/docs/guides/marketplace-upstreams.md
    The code raises UsageError when neither is provided. Both are listed as optional with no indication at least one is mandatory. User who omits both gets runtime error with no doc warning.
    Suggested: In the options table annotate: 'One of --ref or --branch is required. They are mutually exclusive.' Mirror this in cli-commands.md.

  • [recommended] Two planned features mentioned inline without :::note[Planned] callouts at docs/src/content/docs/guides/marketplace-upstreams.md
    Persona rules require planned features use Starlight callouts. Two forward references appear as prose: 'future distribution: rehost mode' and 'future apm marketplace upstream refresh'.
    Suggested: Replace inline parentheticals with :::note[Planned] Starlight callouts.

  • [nit] Quick Start uses real third-party repo and real commit SHA at docs/src/content/docs/guides/marketplace-upstreams.md
    If abhigyanpatwari/GitNexus is renamed/deleted, example silently breaks. cli-commands.md correctly uses placeholder SHAs.
    Suggested: Replace real SHA with a clearly fictitious placeholder (e.g. abcdef1234567890abcdef1234567890abcdef12).

  • [nit] Nav entry label casing differs from guide frontmatter title at docs/astro.config.mjs
    Nav uses 'Marketplace Upstreams' (title case); frontmatter uses 'Marketplace upstreams' (sentence case).

Test Coverage Expert

  • [blocking] Full upstream build pipeline has zero integration-with-fixtures tests; only unit-level mocks exist at tests/integration/marketplace/test_build_integration.py
    The entire upstream add -> build -> lockfile write flow is covered only by unit tests where UpstreamCache.fetch_callback is always MagicMock. tests/integration/marketplace/ contains zero 'upstream' references. The cross-module surface (upstream_parser -> upstream_resolver -> upstream_cache -> builder -> lockfile) requires integration-with-fixtures coverage per tier floor.
    Proof (missing at tests/integration/marketplace/test_build_integration.py::test_upstream_build_writes_lockfile_provenance): assert 'upstreams' in yaml.safe_load(lockfile_path.read_text()); assert lock['upstreams']['gitnexus']['manifest_sha'] == expected_sha -- proves: Running apm marketplace build with an upstream entry writes correct provenance to apm.lock.yaml [governed-by-policy,portability-by-manifest]

  • [recommended] apm marketplace upstream CLI tested only via CliRunner (unit tier); no subprocess-level integration test at tests/unit/commands/test_marketplace_upstream.py
    CliRunner runs in-process without subprocess isolation, real CWD pivoting, or binary exit-code capture. No test in tests/integration/ invokes 'apm marketplace upstream' via subprocess.
    Proof (passed at tests/unit/commands/test_marketplace_upstream.py::TestUpstreamAdd.test_branch_requires_allow_head): assert result.exit_code != 0; assert 'allow-head' in result.output.lower() -- proves: apm marketplace upstream add rejects a branch ref without --allow-head [devx,governed-by-policy]

  • [recommended] Lockfile upstream provenance round-trip tested at unit tier only at tests/unit/marketplace/test_lockfile_upstreams.py
    All 7 lockfile test invocations use MagicMock fetch_callback. A snapshot-fixture approach (canned manifest.json on disk, real UpstreamCache.get/put cycle) would certify the full read+write+diff contract without live network.
    Proof (passed at tests/unit/marketplace/test_lockfile_upstreams.py::TestLockFileUpstreams.test_yaml_round_trip_preserves_upstreams): restored = LockFile.from_yaml(yaml_str); assert restored.upstreams['gitnexus'].manifest_sha == SHA_UPSTREAM_MANIFEST -- proves: apm.lock.yaml upstream provenance survives YAML serialize/deserialize round-trip [governed-by-policy,secure-by-default]

This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.

Generated by PR Review Panel for issue #1145 · ● 4.2M ·

sergio-sisternes-epam pushed a commit that referenced this pull request May 5, 2026
Wave C (Recommended):
- C1: add --yes/-y flag to 'upstream remove' with pre-validation before prompt
- C2: require explicit --plugin when --upstream is set (remove silent default)
- C3: pack success line shows 'N direct + M upstream' breakdown when upstreams > 0
- C4: consolidate _REMOTE_SOURCE_RE into OWNER_REPO_RE in ref_resolver.py;
      import in upstream_cache, upstream_parser, and yml_schema (DRY)
- C5: add content_sha256 to UpstreamCache meta.json; verify on cache hit
- C6-C8: docs -- move trust model to bottom of marketplace-upstreams.md,
          add :::note[Planned] callouts for refresh and apm doctor,
          replace live SHA with fictitious placeholder,
          add cross-link from marketplace-authoring.md to upstreams guide

Wave D (Nits):
- D1: replace tree_item() with logger.info() for flat list rows
- D2: fix (s) pluralisation -- '1 upstream' / '2 upstreams'
- D3: remove unused List/Optional imports from yml_editor.py
- D4: nav label 'Marketplace Upstreams' -> 'Marketplace upstreams' (sentence-case)
- D5: scrub str(exc) -> type(exc).__name__ in upstream_cache.py log messages

Test updates: fix TestUpstreamRemove tests to pass --yes, update alias to
-bad-alias (digit-leading now valid per B5), update pack count test assertions
to match new 'N direct + M upstream' format.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sergio-sisternes-epam
Copy link
Copy Markdown
Collaborator Author

Response to Copilot and APM Panel feedback

Thank you both for the thorough review -- the panel's two-blocking-item framing was exactly right, and Copilot's inline comments provided precise line-level guidance. All 10 Copilot inline comments and all panel findings (blocking, recommended, nits) have been addressed across four commits on this branch.


Copilot inline comments

1. builder.py -- canonical_full_name=None disables rename guard (non-outdated)
Fixed in 2da2d39a (A1). The builder now reads lock.upstreams on entry and populates canonical_full_name from the prior lockfile entry for each upstream. The guard is now operational on every subsequent build. On first build (no prior lockfile) it remains None by necessity, and a visible [!] Rename guard inactive on first build -- re-run after lockfile is generated warning is emitted. The trust table was updated to reflect this precisely: "Repo-rename / takeover guard: YES (from second build onwards; first build deferred by design)".

2. upstream_parser.py -- strict parser rejects APM/Claude marketplace shape (outdated -- fixed)
Fixed in 2da2d39a (B3). The parser now infers type: github when the source.type discriminator is absent, accepting the {"source": "github", ...} shape that APM's own MarketplaceBuilder emits. Curators can now upstream another APM/Claude marketplace.

3. builder.py -- branch names accepted as valid pins (non-outdated)
Fixed in 2da2d39a (B1). ref_to_sha() now rejects non-SHA, non-tag refs (branch names) unless allow_head: true is explicitly set in the upstream entry. Passing a branch ref without the opt-in raises a clear BranchRefForbiddenError. Reproducibility and governance contracts enforced.

4. upstream_parser.py -- hard-coded github.com breaks GHE (outdated -- fixed)
Fixed in 2da2d39a (B4). Removed the hard-coded github.com restriction from the strict parser. Host validation now accepts any HTTPS host, consistent with the host: field and the GHE/GHES support advertised in the CLI reference.

5. yml_editor.py -- alias regex stricter than schema (outdated -- fixed)
Fixed in 2da2d39a (B5). Aligned the editor's alias regex (^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$) with the schema's. Digit-leading aliases are now accepted by both layers.

6. builder.py -- lock.upstreams written but never read back (non-outdated)
Fixed in 2da2d39a (A1). The builder now reads the existing lock.upstreams block at the start of resolve() and threads the stored canonical_full_name into each _UpstreamResolverFactory.build() call. Previously resolved SHAs are seeded into the resolver before any network calls.

7. cli-commands.md -- docs reference --yes but Click command doesn't define it (non-outdated)
Fixed in d33cc263 (C1). --yes/-y is now a real Click option on upstream remove. Uses remove_upstream_entry(dry_run=True) to run all validation before showing the confirmation prompt; unknown aliases still exit 2 without a prompt.

8. CHANGELOG.md -- multi-line format deviates from convention (outdated -- fixed)
Fixed in 2da2d39a (B6). Reformatted to the repo's single-line convention: one concise line ending with (#1145) under ## [Unreleased].

9. upstream_resolver.py:495 -- tag refs left without SHA (mutable) (non-outdated)
Fixed in 2da2d39a (B2). _maybe_sha() for non-SHA refs now triggers a list_remote_refs call to resolve the tag to its commit SHA, which is written to the lockfile. Tags are no longer treated as pinned without a SHA.

10. upstream_resolver.py -- upstream manifests with tag refs leave plugin_sha=None (outdated -- fixed)
Fixed in 2da2d39a (B2). Same resolution logic applied to upstream-manifest-sourced package entries. plugin_sha is populated via tag resolution for entries that carry a ref but no sha.


APM Panel findings

Blocking (both resolved)

[1] Supply Chain Security -- trust table claims rename guard active, code falsifies it
Fixed in 2da2d39a (A1). canonical_full_name is now wired from the lockfile on every build after the first. The trust table in docs/src/content/docs/enterprise/security.md is updated to match reality. The guard is genuinely active from the second build onwards; the first-build limitation is explicitly documented and surfaced at runtime with a warning.

[2] Test Coverage -- entire upstream build -> lockfile write pipeline has no integration-with-fixtures test
Fixed in dbba333c (A2). Added tests/integration/marketplace/test_upstream_build_integration.py with three tests against real fixture files:

  • test_mixed_build_emits_both_entries -- build emits direct + upstream entries without metadata.apm.* keys
  • test_lockfile_records_upstream_provenance -- lockfile captures manifest_sha + resolved_sha from upstream resolution
  • test_offline_rebuild_is_byte_identical -- rebuild from pre-populated cache without network is byte-identical to first build

Registered in scripts/test-integration.sh. All three pass.

Recommended (all resolved in d33cc263)

  • C1 -- --yes on upstream remove: Implemented. See Copilot comment Added ghe support #7 above.
  • C2 -- --plugin required when --upstream set: plugin/add.py now raises a clear error if --upstream is given without --plugin. Silent default to --name is gone.
  • C3 -- Pack count opacity: _render_marketplace_result() now emits "Built marketplace.json (N direct + M upstream = T package(s))" when upstreams are present.
  • C4 -- OWNER_REPO_RE duplicated 3x: Consolidated into ref_resolver.py (exported via __all__); all three consumers import from there.
  • C5 -- content_sha256 missing from cache meta: UpstreamCache.put() now stores content_sha256. get() re-serialises on load and compares; tampered cache content is rejected.
  • C6-C8 -- Doc improvements: Trust model moved to bottom of security guide; :::note[Planned] callouts added around upstream refresh and apm doctor; fictitious SHA placeholder in examples; cross-link added from marketplace-authoring.md to the upstreams guide.

Nits (all resolved in d33cc263)

  • D1: tree_item replaced with logger.info(..., symbol="info") for upstream list rows.
  • D2: Proper singular/plural: "1 upstream" / "2 upstreams".
  • D3: Removed unused List/Optional imports from yml_editor.py.
  • D4: Nav label corrected to sentence-case 'Marketplace upstreams' in astro.config.mjs.
  • D5: str(exc) -> type(exc).__name__ in cache log messages.

Final state

Check Status
Unit tests 7,551 passed
New integration tests 3 passed (test_upstream_build_integration.py)
Lint (ruff check + ruff format --check) Clean
Copilot inline comments 10/10 addressed
Panel blocking findings 2/2 resolved
Panel recommended findings 5/5 resolved
Panel nits 5/5 resolved

Ready for a second review pass.

Copilot and others added 13 commits May 5, 2026 21:51
Introduce the type-level discriminated union for marketplace authoring:

- New 'Upstream' dataclass for marketplace.upstreams[] entries (alias,
  repo, path, ref, branch, host, allow_head); reproducibility guard
  rejects missing ref unless allow_head is opted in.
- New 'UpstreamPackageEntry' dataclass for upstream-sourced packages[]
  entries (name, upstream_alias, plugin, optional curator overrides).
- 'PackageEntry' (the existing direct shape) unchanged; new public
  alias 'DirectPackageEntry' added for explicit naming, plus
  'MarketplacePackage' union alias.
- Strict per-shape key sets (_DIRECT_PACKAGE_KEYS,
  _UPSTREAM_PACKAGE_KEYS, _UPSTREAM_REGISTRATION_KEYS) catch typos in
  the wrong shape at parse time.
- _parse_package_entry now dispatches by shape (presence of source vs
  upstream); both shapes can coexist in the same packages[] list.
- Cross-validation: every upstream-sourced entry references a declared
  upstreams[] alias; cross-shape package name uniqueness enforced
  (prevents dependency-confusion / shadowing); duplicate
  (upstream_alias, plugin) pairs rejected.
- Anthropic-conformant emission contract preserved: this commit adds
  no marketplace.json output keys.

23 new unit tests cover both shapes, mutex validation, alias/host/repo
syntax, path traversal, cross-validation, and the additive guarantee
for existing curators.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New module 'marketplace/upstream_parser.py' is the curator-side
counterpart to the lenient 'parse_marketplace_json'. Where the lenient
parser silently drops unrecognised entries (correct for consumer
browse), the strict parser returns named per-entry rejections so
curators see exactly which upstream plugin was refused and why.

- Public API: 'parse_marketplace_strict(data, *, upstream_owner_repo,
  upstream_host)' returning a 'StrictManifest' with both accepted
  'plugins' (StrictPlugin) and 'rejections' (StrictRejection).
- Closed catalogue 'REJECTION_REASONS' enumerates every named reason
  ('missing-source', 'invalid-repo', 'npm-source',
  'unsupported-source-type', 'unsupported-host', 'path-traversal',
  'invalid-ref', 'invalid-sha', 'unknown-source-key',
  'unknown-plugin-key', 'duplicate-name', etc.) so CLI/builder
  diagnostics can pattern-match deterministically.
- Source-shape support matrix (v1):
  * 'repository: owner/repo' (Copilot CLI shape).
  * 'source: {type: github, repo, ref, sha}' (Claude Code).
  * 'source: {type: git-subdir, repo, path, ref, sha}'.
  * 'source: "./foo"' / '"foo"' string-shorthand resolved as a
    git-subdir of the upstream marketplace's own repo, gated by
    'metadata.pluginRoot'. Routes through 'path_security.
    validate_path_segments' so '..' / '%2e%2e' / absolute paths
    are rejected.
- All non-github hosts and 'npm' / 'tarball' / arbitrary URL sources
  are explicit rejections with named reasons (no silent skip).
- Strict 'find_plugin' is case-sensitive (vs. the lenient
  case-insensitive lookup) so curator typos in 'plugin: <name>'
  fail loudly.

42 new tests in 'test_upstream_parser.py' cover every supported source
shape, every named rejection reason, partial-success multi-entry
behaviour, duplicate-name detection, and a real-world fixture mirroring
'abhigyanpatwari/GitNexus/marketplace.json'.

Smoke-tested live against the GitNexus upstream:
    plugins: [('gitnexus', 'git-subdir', 'abhigyanpatwari/GitNexus',
              'gitnexus-claude-plugin')]
    rejections: ()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduces ``apm_cli.marketplace.upstream_cache`` -- the curator-side
cache used by the upcoming ``UpstreamResolver`` when building a
marketplace whose packages are sourced from an external marketplace.

Design contract
- Content-addressed by manifest SHA; no TTL, immutable per SHA.
- Windows-safe key: ``__`` delimiter (not ``::``); composite is
  SHA-256-hashed and hex-truncated for filesystem safety.
- Defence-in-depth: rejects ``__`` in inputs; ``validate_path_segments``
  on path inputs; integrity check on every cache hit (recorded SHA
  must match requested SHA, else miss).
- Per-upstream-host auth via ``AuthResolver.try_with_fallback`` with
  ``unauth_first=True`` to avoid attaching the curator's PAT to
  public upstream repos (supply-chain panel item 3).
- ``classify_host`` guard refuses non-GitHub hosts in v1, mirroring
  the strict parser's allow-list.
- Offline mode: ``get_or_fetch(..., offline=True)`` raises a named
  error on miss instead of touching the network.

Tests (44 new, all passing)
- Cache-key validation: Windows safety, hash stability, delimiter
  rejection, SHA shape, host shape, path traversal.
- Read/write: hit, miss, integrity mismatch -> miss, corrupt
  manifest/meta -> miss, separate dirs per key.
- get_or_fetch: hit skips fetch, miss fetches+caches, offline raises.
- Default fetch callback: classify-host guard refuses gitlab.com,
  unauth_first path attaches no Authorization header, auth path
  attaches token only on GitHub-family hosts, 404 surfaces as named
  error.

Lint: ``ruff check`` and ``ruff format --check`` both silent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduces apm_cli.marketplace.upstream_resolver -- the layer between
the schema dataclasses and MarketplaceBuilder. It owns three invariants:

1. Atomic-fetch: each registered Upstream is fetched + strict-parsed
   at most once per build, even when many packages reference the same
   upstream alias.
2. Repo-rename guard: compares API-reported canonical full_name
   against the configured upstream.repo (case-insensitive) and fails
   closed on mismatch.
3. Precedence ladder: curator-ref > curator-version > upstream-pin
   > upstream-registration-ref (same-repo fallback) > branch-head
   (allow_head).

All I/O is dependency-injected (ref_to_sha, canonical_full_name,
version_range_resolver) so unit tests never touch the network.
Strict-parser rejections are hoisted into the resolver's diagnostic
stream as build errors, ready for the builder to surface as exit-
code-2 failures.

21 new unit tests; 1035/1035 marketplace tests pass; lint pair silent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire UpstreamResolver into MarketplaceBuilder.build() so apm.yml
packages declared with the upstream/plugin shape resolve to vanilla
plugin entries in the emitted marketplace.json.

Changes
- builder.resolve() partitions the union of PackageEntry into direct
  vs. upstream entries, drives upstream resolution via a per-host
  RefResolver wiring (curator host reuses authenticated resolver;
  foreign hosts get unauthenticated resolver), and lifts upstream
  diagnostics into the BuildReport.
- compose_marketplace_json gains an upstream_resolved kwarg and emits
  upstream plugins after direct ones (no interleaving in v1). Curator
  overrides win for description/version/tags; author/license/
  repository/homepage stay curator-only -- matching the direct-package
  contract.
- BuildDiagnostic gains an optional code field; level enum extended
  with 'error'.
- ResolveResult and BuildReport carry upstream_entries +
  upstream_diagnostics.
- build() round-trip-validates output via the lenient
  parse_marketplace_json (asserts plugin count) and fail-closes BEFORE
  composing/writing when upstream resolution emits a level=error
  diagnostic. Direct continue_on_error semantics are preserved.

Tests
- 7 new builder-upstream integration tests cover mixed-shape emission,
  upstream-only builds, no metadata.apm.* injection, curator-override
  precedence, git-subdir source shape, round-trip parsing, and the
  fail-closed gate on resolution errors.
- 1042/1042 marketplace tests green (1035 prior + 7 new).
- Lint pair silent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend LockFile with an upstreams: section that captures, for every
upstream-sourced package emitted to the curator's marketplace.json,
the upstream marketplace coordinates (host/owner/repo/path/manifest
SHA + canonical_full_name guard) and the per-plugin pin (resolved
SHA + emitted display name + resolved source dict).

Provenance lives in apm.lock.yaml only. marketplace.json stays
Anthropic-conformant -- no metadata.apm.* keys are injected -- which
preserves the hard rule documented in builder.py.

Changes
- deps/lockfile.py: new LockedUpstream and LockedUpstreamPlugin
  dataclasses with to_dict / from_dict serialisers; LockFile.upstreams
  field; YAML write emits a sorted, deterministic upstreams: block;
  YAML read restores it.
- marketplace/builder.py: build() calls a new
  _write_upstream_lockfile helper after the atomic marketplace.json
  write. The helper loads the existing lockfile, replaces the
  upstreams: block from the freshly resolved upstream packages, and
  rewrites the file. Empty-upstream builds are no-ops (existing block
  preserved).

Tests
- 7 new tests cover dataclass round-trip, deterministic sorted plugin
  output, LockFile YAML round-trip, builder integration writes the
  upstream section with all required fields, and the
  no-metadata.apm.* invariant still holds.
- 1147/1147 marketplace + lockfile tests pass.
- Lint pair silent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the curator-facing CLI surface for managing upstream marketplaces,
backed by the upstream pipeline already wired through schema -> parser ->
cache -> resolver -> builder -> lockfile.

Editor (yml_editor.py):
- add_upstream_entry / remove_upstream_entry / list_upstream_entries
- alias regex (case-sensitive), repo regex, dangling-reference guard on
  remove (rejects when any package still references the alias)
- round-trip ruamel preserves comments and whitespace

CLI (commands/marketplace/upstream.py):
- 'apm marketplace upstream add <repo> --alias --ref|--branch [--path]
  [--host] [--allow-head] [--no-verify]'
- 'apm marketplace upstream list'
- 'apm marketplace upstream remove <alias>'
- mutually exclusive --ref / --branch; --branch requires --allow-head
- mutable refs auto-resolved to SHA via ls-remote (offline-tolerant)
- exit code 2 on validation / resolution errors

Registration:
- 'upstream' added to _authoring_commands group ordering
- subgroup wired in commands/marketplace/__init__.py

Tests:
- tests/unit/marketplace/test_yml_editor_upstreams.py (15 tests)
- tests/unit/commands/test_marketplace_upstream.py (10 tests)
- 1107 marketplace + plugin + upstream tests green
- Lint pair silent

Refs: #1136

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CLI:
- 'apm marketplace package add --upstream <alias> --plugin <name>' now
  exposes a plugin from a registered upstream marketplace under a
  curator-chosen display name. Mutually exclusive with positional SOURCE
  and with --subdir.
- New 'add_upstream_package_entry' helper in yml_editor: validates
  alias is registered, enforces (upstream, plugin) tuple uniqueness and
  cross-shape name uniqueness against existing direct entries.

Docs:
- New guide: docs/.../guides/marketplace-upstreams.md (concept, trust
  model table, schema, quick start, CLI reference, reproducibility, v1
  scope contract).
- enterprise/security.md: rewrites the 'no proxy, no mirror' claim to
  reflect curated-pass-through trust model + cross-link to upstreams
  guide. APM still does not re-host upstream content.
- packages/apm-guide/.apm/skills/apm-usage/commands.md: 4 new rows for
  'package add --upstream' and the 'upstream' subgroup.
- packages/apm-guide/.apm/skills/apm-usage/package-authoring.md: adds
  the upstreams: block + upstream-sourced packages[] schema example.
- CHANGELOG.md: Unreleased entry.

Tests:
- 5 new CLI tests covering --upstream xor SOURCE, neither, unknown
  alias (exit 2), --subdir rejection, and the happy path.
- 1112 marketplace + plugin + upstream tests green.
- Lint pair silent.

Refs: #1136

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#1136)

After integrating upstream resolution into MarketplaceBuilder, the
success/dry-run log line still counted only direct packages
('len(report.resolved)') and would print '0 package(s)' when the
marketplace contained only upstream-sourced entries -- discovered
during the local E2E against abhigyanpatwari/GitNexus.

Sum direct + upstream resolved counts so the user sees the actual
emitted plugin count.

Refs: #1136

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Applies findings from local APM Review Panel pass:

Blocking (docs):
- Register new 'Marketplace Upstreams' guide in Starlight sidebar
  (docs/astro.config.mjs); previously orphaned from nav.
- Fix mislabelled quick-start step: 'apm marketplace upstream list'
  shows registered upstreams, not their plugin contents.
- Document 'apm marketplace upstream {add,list,remove}' subcommand
  group and extend 'package add' with --upstream/--plugin/--name/
  --allow-head in reference/cli-commands.md.

Functional bugs (auth + cli-logging):
- upstream.py CLI helpers (_verify_upstream_repo, _resolve_upstream_
  ref_to_sha) now build RefResolver via AuthResolver.resolve(host,
  org=owner) and pass owner/repo (not full URL) to list_remote_refs.
  Prior code constructed RefResolver() with no host/token and double-
  prefixed the URL through build_https_clone_url, breaking GHE/GHES
  upstreams and any private upstream repo. Public github.com path
  was unaffected.
- builder.py: drop hardcoded [x]/[!]/[i] prefixes from
  BuildDiagnostic.message text; CommandLogger adds the symbol via the
  rendering layer, so the prior code would double-prefix.
- builder.py: warning-level upstream resolver diagnostics are now
  also lifted into report.warnings so the existing
  pack._render_marketplace_result rendering path surfaces them.
  Previously they only reached report.diagnostics, which no caller
  consumed -- mutable-pin warnings were silently dropped.

Defence-in-depth (supply-chain):
- upstream_cache._fetch_marketplace_json: catch requests.HTTPError
  explicitly and re-raise UpstreamCacheError with status + URL only,
  using 'from None' to drop the response object from __cause__. Prior
  chain retained exc.response.request.headers which carries the
  Authorization: token <PAT> header, leaking the curator's token to
  any logger that walked __cause__.

Test coverage:
- New tests/unit/commands/test_pack_upstream_count.py pins the
  pack-count fix at pack.py:216 -- asserts 'Built marketplace.json
  (N package(s))' and 'Would write...' messages count
  len(resolved) + len(upstream_resolved) for empty, direct-only,
  upstream-only, and mixed builds.

Validation: ruff check + format --check both silent; full unit suite
7546 passed, 1 warning, 29 subtests.

Deferred (low signal / scope creep): SHA-40 regex consolidation,
_build_upstream_resolver factory extraction, integration-with-fixtures
test for full pack flow, automated byte-identical reproducibility
test, list-cmd tree-item polish, remove-success symbol nit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply remaining recommended + nit findings from APM Review Panel
local pass on the marketplace-upstreams branch. All 8 panelists
already had their blocking findings landed in 2da2d39; this
commit lands the long tail.

py-arch:
- Extract _build_upstream_resolver 100+ line closure stack into
  _UpstreamResolverFactory holding host_resolvers cache + ref/version
  resolvers as instance state. _build_upstream_resolver becomes a
  thin assembly point; existing test patches still work.
- Consolidate the 40-char SHA regex from 5 marketplace + plugin-CLI
  call sites into ref_resolver.FULL_SHA_RE (single source of truth).
  The deps/github_downloader_validation regex (7-40 hex,
  case-insensitive) is intentionally left alone -- different semantics.

cli-logging:
- upstream list per-entry rows render via tree_item() (no [i] prefix)
  to avoid the double-symbol look under the count line.
- upstream remove success uses the default sparkles ([*]) symbol;
  [+] (check) reads as 'addition' in this codebase.

supply-chain:
- LockedUpstreamPlugin.resolved_sha typed as str | None; from_dict
  preserves None instead of silently coercing to ''. Required for
  pin_source='branch-head' (allow_head opt-in) round-trip.
- Fix inverted comment at upstream_cache.put(): manifest is written
  before meta, and the comment now matches the atomicity contract
  (incomplete entry = manifest without meta = skipped on read).

oss-growth + doc-writer:
- Replace <40-char-sha-or-tag> placeholder with a real GitNexus SHA
  + 'how to get the current SHA' git ls-remote hint.
- Rewrite CHANGELOG [Unreleased] entry as a one-line hook + bullets;
  link to rendered docs URL instead of a repo-relative file path.
- Reword 'warned' to 'requires explicit --allow-head opt-in' in the
  CLI reference table; --allow-head is a hard opt-in, not a warning.
- Add 'Tag vs SHA: when to use which' subsection to the guide.
- Add 'Failure modes' subsection with the three named errors curators
  will hit (unknown alias, repo-rename, moving branch without
  --allow-head).

auth (nit defer with rationale):
- Expand the canonical_full_name=None comment in
  _build_upstream_resolver to spell out the v1 trade-off:
  manifest-SHA round-trip already provides tamper detection;
  resolving the canonical name would require an authenticated GitHub
  REST call per upstream on every build, widening curator PAT scope.

upstream_resolver.resolve_all docstring documents the
sequential-only concurrency decision (fan-out is the per-upstream
cache, manifest fetches dominate, threading hurts error attribution
without measurable speedup at the current curator scale).

test-coverage:
- New CLI-layer duplicate-alias test
  (TestUpstreamAdd::test_duplicate_alias_exits_2): re-adding an
  existing alias hard-fails at exit code 2 and leaves the original
  entry untouched.
- New byte-identical reproducibility regression trap
  (test_byte_identical_rebuild_produces_same_output): two consecutive
  builds against the same mixed apm.yml produce identical bytes.
- New regression trap on direct-package emission
  (test_upstream_does_not_mutate_direct_package_emission): adding an
  upstream entry must not change the direct subset's emitted JSON.

Validation:
- ruff check + format --check both silent.
- pytest tests/unit -q: 7549 passed, 1 warning, 29 subtests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wave C (Recommended):
- C1: add --yes/-y flag to 'upstream remove' with pre-validation before prompt
- C2: require explicit --plugin when --upstream is set (remove silent default)
- C3: pack success line shows 'N direct + M upstream' breakdown when upstreams > 0
- C4: consolidate _REMOTE_SOURCE_RE into OWNER_REPO_RE in ref_resolver.py;
      import in upstream_cache, upstream_parser, and yml_schema (DRY)
- C5: add content_sha256 to UpstreamCache meta.json; verify on cache hit
- C6-C8: docs -- move trust model to bottom of marketplace-upstreams.md,
          add :::note[Planned] callouts for refresh and apm doctor,
          replace live SHA with fictitious placeholder,
          add cross-link from marketplace-authoring.md to upstreams guide

Wave D (Nits):
- D1: replace tree_item() with logger.info() for flat list rows
- D2: fix (s) pluralisation -- '1 upstream' / '2 upstreams'
- D3: remove unused List/Optional imports from yml_editor.py
- D4: nav label 'Marketplace Upstreams' -> 'Marketplace upstreams' (sentence-case)
- D5: scrub str(exc) -> type(exc).__name__ in upstream_cache.py log messages

Test updates: fix TestUpstreamRemove tests to pass --yes, update alias to
-bad-alias (digit-leading now valid per B5), update pack count test assertions
to match new 'N direct + M upstream' format.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ild pipeline (A2)

Adds three end-to-end fixture tests for the upstream build pipeline
without network calls:

- test_mixed_build_emits_both_entries: verifies a direct+upstream apm.yml
  produces marketplace.json with both plugin entries and no metadata.apm.*
  keys (Anthropic-conformant pass-through contract).
- test_lockfile_records_upstream_provenance: verifies apm.lock.yaml
  records manifest_sha and resolved plugin sha for the upstream alias.
- test_offline_rebuild_is_byte_identical: verifies that an offline
  rebuild using the populated lockfile produces byte-identical output
  (reproducibility invariant).

Both the direct package (pinned SHA ref) and upstream manifest
(pre-populated UpstreamCache from fixture dict) bypass the network
entirely, making these tests fast and deterministic.

Also registers the new test file in scripts/test-integration.sh so
it runs in CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sergio-sisternes-epam sergio-sisternes-epam force-pushed the feature/marketplace-upstreams-1136 branch from dbba333 to 3394398 Compare May 5, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status/triaged Initial agentic triage complete; pending maintainer ratification (silence = approval).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Marketplace upstreams: curated pass-through with allow-list governance

2 participants